[id].vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. <template>
  2. <div class="admin--page-content">
  3. <div v-if="isLoading" class="admin--table-loading" style="padding:60px;text-align:center;">
  4. 데이터를 불러오는 중...
  5. </div>
  6. <div v-else-if="!challenge" class="admin--table-empty" style="padding:60px;text-align:center;">
  7. 해당 챌린지를 찾을 수 없습니다.
  8. <div class="mt--16">
  9. <button class="admin--btn" @click="goToList">← 목록으로</button>
  10. </div>
  11. </div>
  12. <template v-else>
  13. <!-- ============================
  14. 메인 탭
  15. ============================ -->
  16. <div class="admin--main-tabs">
  17. <button
  18. type="button"
  19. :class="{ 'is-active': activeMainTab === 'challenge' }"
  20. @click="activeMainTab = 'challenge'"
  21. >
  22. 챌린지관리
  23. </button>
  24. <button
  25. type="button"
  26. :class="{ 'is-active': activeMainTab === 'applicants' }"
  27. @click="activeMainTab = 'applicants'"
  28. >
  29. 신청자관리
  30. </button>
  31. <button
  32. type="button"
  33. :class="{ 'is-active': activeMainTab === 'participants' }"
  34. @click="activeMainTab = 'participants'"
  35. >
  36. 참가자관리
  37. </button>
  38. </div>
  39. <!-- ============================
  40. 신청자관리 (준비중)
  41. ============================ -->
  42. <div v-if="activeMainTab === 'applicants'" class="admin--placeholder">
  43. <p>📝 신청자관리는 준비중입니다.</p>
  44. </div>
  45. <!-- ============================
  46. 참가자관리 (준비중)
  47. ============================ -->
  48. <div v-else-if="activeMainTab === 'participants'" class="admin--placeholder">
  49. <p>👥 참가자관리는 준비중입니다.</p>
  50. </div>
  51. <!-- ============================
  52. 챌린지관리 (기본 활성)
  53. ============================ -->
  54. <div v-show="activeMainTab === 'challenge'" class="admin--form">
  55. <table class="admin--form--table">
  56. <colgroup>
  57. <col style="width: 140px;">
  58. <col>
  59. </colgroup>
  60. <tbody>
  61. <tr>
  62. <th><div>챌린지명</div></th>
  63. <td>{{ challenge.name }}</td>
  64. </tr>
  65. <tr>
  66. <th><div>참가비</div></th>
  67. <td>{{ formatFee(challenge.fee) }}</td>
  68. </tr>
  69. <tr>
  70. <th><div>기간</div></th>
  71. <td>{{ formatDate(challenge.start_date) }} ~ {{ formatDate(challenge.end_date) }}</td>
  72. </tr>
  73. <tr>
  74. <th><div>최대 참가자</div></th>
  75. <td>{{ challenge.max_participants }}명</td>
  76. </tr>
  77. <tr>
  78. <th><div>총 라운드</div></th>
  79. <td>{{ challenge.total_rounds }}R</td>
  80. </tr>
  81. <tr>
  82. <th><div>타이틀 이미지</div></th>
  83. <td>
  84. <div v-if="challenge.file_path" class="onboard--photo-grid">
  85. <div class="onboard--photo-item">
  86. <img :src="getImageUrl(challenge.file_path)" :alt="challenge.file_name || challenge.name" />
  87. </div>
  88. </div>
  89. <template v-else>-</template>
  90. </td>
  91. </tr>
  92. <tr>
  93. <th><div>현재 상태</div></th>
  94. <td>
  95. <span :class="['admin--badge', statusBadgeClass(challenge.derived_status)]">
  96. {{ statusLabel(challenge.derived_status) }}
  97. </span>
  98. <span v-if="challenge.closed_at" class="txt--muted ml--16">
  99. ({{ formatDateTime(challenge.closed_at) }} 마감)
  100. </span>
  101. </td>
  102. </tr>
  103. <tr>
  104. <th><div>노출 여부</div></th>
  105. <td>
  106. <span :class="['admin--badge', challenge.status_YN === 'Y' ? 'admin--badge-active' : 'admin--badge-ended']">
  107. {{ challenge.status_YN === 'Y' ? '사용중' : '미사용' }}
  108. </span>
  109. </td>
  110. </tr>
  111. <tr>
  112. <th><div>등록일</div></th>
  113. <td>{{ formatDateTime(challenge.created_at) }}</td>
  114. </tr>
  115. <tr v-if="challenge.description">
  116. <th><div>상세내용</div></th>
  117. <td>
  118. <div class="admin--detail--content" v-html="challenge.description"></div>
  119. </td>
  120. </tr>
  121. </tbody>
  122. </table>
  123. <!-- ============================
  124. 라운드 정보 (탭)
  125. ============================ -->
  126. <h3 class="admin--table--middle--title mb--8">라운드 정보</h3>
  127. <div class="admin--round--tabs">
  128. <button
  129. v-for="(round, rIdx) in challenge.rounds"
  130. :key="round.id"
  131. type="button"
  132. class="admin--round--tab"
  133. :class="{ 'is-active': activeRoundIdx === rIdx }"
  134. @click="activeRoundIdx = rIdx"
  135. >
  136. 라운드 {{ round.round_no }}
  137. <span v-if="round.closed_at" class="admin--round--tab__badge">종료</span>
  138. </button>
  139. </div>
  140. <div
  141. v-for="(round, rIdx) in challenge.rounds"
  142. v-show="activeRoundIdx === rIdx"
  143. :key="round.id"
  144. class="admin--round--box--wrap"
  145. >
  146. <div class="admin--round--title">
  147. 라운드 {{ round.round_no }}
  148. <span>
  149. {{ round.place_mode === 'all' ? '전체 장소에 동일 적용' : '장소별 개별 설정' }}
  150. ㆍ 진출자 {{ round.qualified }}{{ rIdx === 0 ? '명' : '%' }}
  151. </span>
  152. <span v-if="round.closed_at" class="closed--txt">
  153. 마감 ({{ formatDateTime(round.closed_at) }})
  154. </span>
  155. <button
  156. v-else-if="currentRoundIdx === rIdx && !challenge.closed_at"
  157. type="button"
  158. class="admin--btn-small admin--btn-red"
  159. @click="confirmCloseRound(round)"
  160. >
  161. 라운드 마감
  162. </button>
  163. </div>
  164. <div class="admin--round--box">
  165. <!-- all 모드 -->
  166. <div v-if="round.place_mode === 'all'" class="mt--16">
  167. <p class="mb--8">배정 아이템 ({{ round.items.length }})</p>
  168. <ul v-if="round.items.length > 0" class="admin--item-modal__grid">
  169. <li v-for="it in round.items" :key="it.id" class="admin--item-modal__card">
  170. <div style="padding:12px 12px 14px;">
  171. <div class="admin--item-modal__thumb">
  172. <img
  173. v-if="it.file_path"
  174. :src="getImageUrl(it.file_path)"
  175. :alt="it.name"
  176. />
  177. <div v-else class="admin--item-modal__no-img">🎁</div>
  178. </div>
  179. <div class="admin--item-modal__name">{{ it.name || '?' }}</div>
  180. <div class="admin--item-modal__meta">
  181. <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
  182. <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
  183. </div>
  184. </div>
  185. </li>
  186. </ul>
  187. <p v-else class="txt--muted">배정된 아이템이 없습니다.</p>
  188. </div>
  189. <!-- specific 모드 -->
  190. <template v-else>
  191. <!-- <p class="mb--8">장소 묶음 ({{ round.places.length }})</p> -->
  192. <div
  193. v-for="(place, pIdx) in round.places"
  194. :key="place.group_no"
  195. class="round--place--wrap"
  196. :class="{ 'mt--16': pIdx > 0 }"
  197. >
  198. <div class="admin--round--title">
  199. 장소 {{ pIdx + 1 }}
  200. <span class="txt--muted">{{ placeCountText(place.onboards) }}</span>
  201. </div>
  202. <div class="place--select--wrap">
  203. <p class="mt--8 mb--4">장소 목록</p>
  204. <div class="area--group--wrap">
  205. <div
  206. v-for="group in chipsByArea(place)"
  207. :key="group.area"
  208. class="area--group"
  209. >
  210. <p class="area--group__name">📍 {{ group.area }}</p>
  211. <div class="item--selected--wrap">
  212. <div
  213. v-for="chip in group.chips"
  214. :key="chip.key"
  215. :class="[
  216. 'item--selected',
  217. chip.type === 'group' ? 'is-group is-clickable' : '',
  218. chip.type === 'collapse' ? 'is-collapse is-clickable' : '',
  219. chip.type === 'expanded' && chip.placeType === 'onboard' ? 'onboard' : '',
  220. chip.type === 'single' && chip.placeType === 'onboard' ? 'onboard' : ''
  221. ]"
  222. @click="(chip.type === 'group' || chip.type === 'collapse') && toggleExpandedArea(place, chip.area)"
  223. >
  224. {{ chip.icon }} {{ chipLabelInArea(chip) }}
  225. </div>
  226. </div>
  227. </div>
  228. </div>
  229. <p class="mt--16 mb--4">배정 아이템 ({{ place.items.length }})</p>
  230. <ul v-if="place.items.length > 0" class="admin--item-modal__grid">
  231. <li v-for="it in place.items" :key="it.id" class="admin--item-modal__card">
  232. <div style="padding:12px 12px 14px;">
  233. <div class="admin--item-modal__thumb">
  234. <img
  235. v-if="it.file_path"
  236. :src="getImageUrl(it.file_path)"
  237. :alt="it.name"
  238. />
  239. <div v-else class="admin--item-modal__no-img">🎁</div>
  240. </div>
  241. <div class="admin--item-modal__name">{{ it.name || '?' }}</div>
  242. <div class="admin--item-modal__meta">
  243. <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
  244. <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
  245. </div>
  246. </div>
  247. </li>
  248. </ul>
  249. <p v-else class="txt--muted">배정된 아이템이 없습니다.</p>
  250. </div>
  251. </div>
  252. </template>
  253. </div>
  254. </div>
  255. <!-- ============================
  256. 액션 버튼
  257. ============================ -->
  258. <div class="admin--form-actions">
  259. <button type="button" class="admin--btn" @click="goToList">
  260. ← 목록으로
  261. </button>
  262. <button type="button" class="admin--btn admin--btn-blue ml--auto" @click="goToEdit">
  263. 수정
  264. </button>
  265. <button type="button" class="admin--btn admin--btn-red" @click="confirmDelete">
  266. 삭제
  267. </button>
  268. </div>
  269. <!-- 메시지 -->
  270. <div v-if="successMessage" class="admin--alert admin--alert-success">{{ successMessage }}</div>
  271. <div v-if="errorMessage" class="admin--alert admin--alert-error">{{ errorMessage }}</div>
  272. </div>
  273. </template>
  274. <!-- 삭제 확인 모달 -->
  275. <AdminAlertModal
  276. v-if="showDeleteModal"
  277. title="챌린지 삭제"
  278. :message="`'${challenge?.name}' 챌린지를 삭제하시겠습니까?\n삭제된 챌린지는 복원할 수 있습니다.`"
  279. type="confirm"
  280. @confirm="handleDelete"
  281. @cancel="showDeleteModal = false"
  282. @close="showDeleteModal = false"
  283. />
  284. <!-- 라운드 마감 확인 모달 -->
  285. <AdminAlertModal
  286. v-if="showCloseRoundModal"
  287. title="라운드 마감"
  288. :message="`라운드 ${closingRound?.round_no}을(를) 마감하시겠습니까?\n마지막 라운드일 경우 챌린지도 함께 자동 종료됩니다.`"
  289. type="confirm"
  290. @confirm="handleCloseRound"
  291. @cancel="() => { showCloseRoundModal = false; closingRound = null }"
  292. @close="() => { showCloseRoundModal = false; closingRound = null }"
  293. />
  294. </div>
  295. </template>
  296. <script setup>
  297. import { ref, computed, onMounted } from "vue";
  298. import { useRoute, useRouter } from "vue-router";
  299. import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
  300. definePageMeta({
  301. layout: "admin",
  302. middleware: ["auth"],
  303. });
  304. const route = useRoute();
  305. const router = useRouter();
  306. const { get, post, del } = useApi();
  307. const { getImageUrl } = useImage();
  308. const challengeId = Number(route.params.id);
  309. const isLoading = ref(false);
  310. const challenge = ref(null);
  311. const successMessage = ref("");
  312. const errorMessage = ref("");
  313. const showDeleteModal = ref(false);
  314. const activeRoundIdx = ref(0); // 현재 선택된 라운드 탭
  315. const activeMainTab = ref("challenge"); // 'challenge' | 'applicants' | 'participants'
  316. const showCloseRoundModal = ref(false);
  317. const closingRound = ref(null);
  318. // 현재 라운드 인덱스 (closed_at NULL인 가장 작은 round_no)
  319. const currentRoundIdx = computed(() => {
  320. if (!challenge.value?.rounds?.length) return -1;
  321. const idx = challenge.value.rounds.findIndex((r) => !r.closed_at);
  322. return idx; // -1 이면 모든 라운드 마감 상태
  323. });
  324. const statusLabel = (s) =>
  325. s === "hidden" ? "비노출"
  326. : s === "recruiting" ? "모집중"
  327. : s === "running" ? "진행중"
  328. : s === "ended" ? "종료"
  329. : "-";
  330. const statusBadgeClass = (s) =>
  331. s === "hidden" ? "admin--badge-hidden"
  332. : s === "recruiting" ? "admin--badge-recruiting"
  333. : s === "running" ? "admin--badge-running"
  334. : s === "ended" ? "admin--badge-ended"
  335. : "";
  336. const formatDate = (s) => {
  337. if (!s) return "-";
  338. const d = new Date(s.replace(" ", "T"));
  339. if (isNaN(d.getTime())) return s;
  340. return d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
  341. };
  342. const formatDateTime = (s) => {
  343. if (!s) return "-";
  344. const d = new Date(s.replace(" ", "T"));
  345. if (isNaN(d.getTime())) return s;
  346. return d.toLocaleString("ko-KR", {
  347. year: "numeric", month: "2-digit", day: "2-digit",
  348. hour: "2-digit", minute: "2-digit",
  349. });
  350. };
  351. // 장소 묶음의 type별 카운트 텍스트 ("선상 3개ㆍ낚시터 2개" / 한쪽만 있으면 그쪽만)
  352. const placeCountText = (onboards) => {
  353. const list = onboards || [];
  354. const onb = list.filter((o) => o.place_type === "onboard").length;
  355. const fis = list.filter((o) => o.place_type === "fishing").length;
  356. const parts = [];
  357. if (onb > 0) parts.push(`선상 ${onb}개`);
  358. if (fis > 0) parts.push(`낚시터 ${fis}개`);
  359. return parts.join("ㆍ") || "장소 없음";
  360. };
  361. const formatFee = (fee) => {
  362. if (fee === null || fee === undefined || fee === "") return "-";
  363. const num = Number(String(fee).replace(/[^\d]/g, ""));
  364. if (isNaN(num) || num === 0) return fee + "";
  365. return num.toLocaleString() + "원";
  366. };
  367. // ============================
  368. // 그룹 칩 표시용 — 같은 지역 전체 선택이면 "○○ 전체" 묶음
  369. // ============================
  370. const placesAll = ref([]); // 모든 선상+낚시터 (area_name 포함)
  371. const loadPlacesAll = async () => {
  372. try {
  373. const [onboardRes, fishingRes] = await Promise.all([
  374. get("/onboard/list", { params: { per_page: 1000 } }),
  375. get("/fishing/list", { params: { per_page: 1000 } }),
  376. ]);
  377. const onboards = (onboardRes.data?.success ? (onboardRes.data.data.items || []) : [])
  378. .map((o) => ({ ...o, _placeType: "onboard" }));
  379. const fishings = (fishingRes.data?.success ? (fishingRes.data.data.items || []) : [])
  380. .map((f) => ({ ...f, _placeType: "fishing" }));
  381. placesAll.value = [...onboards, ...fishings];
  382. } catch (e) {
  383. console.error("[Detail] placesAll 로드 실패:", e);
  384. }
  385. };
  386. const placeKey = (p) => `${p._placeType}-${p.id}`;
  387. const onboardObjKey = (o) => `${o.place_type}-${o.place_id}`;
  388. function displayChips(place) {
  389. if (!place.expandedAreas) place.expandedAreas = [];
  390. const selectedKeys = new Set((place.onboards || []).map(onboardObjKey));
  391. const expandedAreas = new Set(place.expandedAreas);
  392. // 지역별 전체 장소 그룹화 (placesAll 기준)
  393. const groupedAll = new Map();
  394. placesAll.value.forEach((p) => {
  395. const area = p.area_name || "미분류";
  396. if (!groupedAll.has(area)) groupedAll.set(area, []);
  397. groupedAll.get(area).push(p);
  398. });
  399. const result = [];
  400. const processedKeys = new Set();
  401. // 1) 같은 지역의 모든 장소가 선택됐고 2개 이상이면 → 그룹 칩 (펼침 가능)
  402. for (const [area, places] of groupedAll.entries()) {
  403. if (places.length < 2) continue;
  404. const groupKeys = places.map(placeKey);
  405. const allSelected = groupKeys.every((k) => selectedKeys.has(k));
  406. if (!allSelected) continue;
  407. if (expandedAreas.has(area)) {
  408. // 펼친 상태 — 개별 칩들 + 접기 칩
  409. for (const p of places) {
  410. result.push({
  411. key: placeKey(p),
  412. type: "expanded",
  413. label: p.name,
  414. icon: p._placeType === "onboard" ? "🚤" : "🎣",
  415. placeType: p._placeType,
  416. area,
  417. });
  418. }
  419. result.push({
  420. key: `collapse:${area}`,
  421. type: "collapse",
  422. label: `${area} 접기`,
  423. icon: "↩",
  424. placeType: null,
  425. area,
  426. });
  427. } else {
  428. // 접힌 상태 — 그룹 칩
  429. result.push({
  430. key: `group:${area}`,
  431. type: "group",
  432. label: `${area} 전체`,
  433. icon: "📍",
  434. placeType: null,
  435. area,
  436. });
  437. }
  438. groupKeys.forEach((k) => processedKeys.add(k));
  439. }
  440. // 2) 남은 onboards는 개별 칩
  441. for (const o of (place.onboards || [])) {
  442. const key = onboardObjKey(o);
  443. if (processedKeys.has(key)) continue;
  444. result.push({
  445. key: `single:${key}`,
  446. type: "single",
  447. label: o.place_name || "(삭제됨)",
  448. icon: o.place_type === "onboard" ? "🚤" : "🎣",
  449. placeType: o.place_type,
  450. });
  451. }
  452. return result;
  453. }
  454. // 그룹/접기 칩 클릭 → 토글
  455. function toggleExpandedArea(place, area) {
  456. if (!place.expandedAreas) place.expandedAreas = [];
  457. const idx = place.expandedAreas.indexOf(area);
  458. if (idx === -1) place.expandedAreas.push(area);
  459. else place.expandedAreas.splice(idx, 1);
  460. }
  461. // 지역별로 묶어서 반환 — 각 지역명 헤더 + 그 안의 칩들
  462. function chipsByArea(place) {
  463. const chips = displayChips(place);
  464. const placeAreaMap = new Map();
  465. placesAll.value.forEach((p) => {
  466. placeAreaMap.set(placeKey(p), p.area_name || "미분류");
  467. });
  468. const groupMap = new Map(); // area => chips[]
  469. for (const chip of chips) {
  470. let area = chip.area;
  471. if (!area) {
  472. // single chip — placesAll에서 area 찾기
  473. if (chip.type === "single") {
  474. area = placeAreaMap.get(chip.key.replace(/^single:/, "")) || "미분류";
  475. } else if (chip.type === "expanded") {
  476. area = placeAreaMap.get(chip.key) || "미분류";
  477. }
  478. }
  479. if (!area) area = "미분류";
  480. if (!groupMap.has(area)) groupMap.set(area, []);
  481. groupMap.get(area).push(chip);
  482. }
  483. return Array.from(groupMap.entries()).map(([area, chips]) => ({ area, chips }));
  484. }
  485. // 헤더에 area명이 표시되므로 칩 라벨에서 area명 제거
  486. function chipLabelInArea(chip) {
  487. if (chip.type === "group") return "전체";
  488. if (chip.type === "collapse") return "접기";
  489. return chip.label;
  490. }
  491. const loadChallenge = async () => {
  492. isLoading.value = true;
  493. try {
  494. const { data, error } = await get(`/challenge/${challengeId}`);
  495. if (error || !data?.success) {
  496. challenge.value = null;
  497. return;
  498. }
  499. challenge.value = data.data;
  500. // 페이지 진입 시 활성 탭 = 현재 라운드 (모두 마감이면 마지막 라운드)
  501. const rounds = challenge.value?.rounds || [];
  502. if (rounds.length) {
  503. const idx = rounds.findIndex((r) => !r.closed_at);
  504. activeRoundIdx.value = idx === -1 ? rounds.length - 1 : idx;
  505. }
  506. } catch (e) {
  507. console.error("[ChallengeDetail] 로드 실패:", e);
  508. challenge.value = null;
  509. } finally {
  510. isLoading.value = false;
  511. }
  512. };
  513. // 라운드 마감 — 확인 모달 → API
  514. const confirmCloseRound = (round) => {
  515. closingRound.value = round;
  516. showCloseRoundModal.value = true;
  517. };
  518. const handleCloseRound = async () => {
  519. showCloseRoundModal.value = false;
  520. const round = closingRound.value;
  521. closingRound.value = null;
  522. if (!round) return;
  523. errorMessage.value = "";
  524. try {
  525. const { data, error } = await post(`/challenge/round/${round.id}/close`, {});
  526. if (error || !data?.success) {
  527. errorMessage.value = error?.message || data?.message || "마감에 실패했습니다.";
  528. return;
  529. }
  530. successMessage.value = data.message || "라운드가 마감되었습니다.";
  531. await loadChallenge();
  532. } catch (e) {
  533. console.error("[ChallengeDetail] 마감 실패:", e);
  534. errorMessage.value = "서버 오류가 발생했습니다.";
  535. }
  536. };
  537. const confirmDelete = () => {
  538. showDeleteModal.value = true;
  539. };
  540. const handleDelete = async () => {
  541. showDeleteModal.value = false;
  542. errorMessage.value = "";
  543. try {
  544. const { data, error } = await del(`/challenge/${challengeId}`);
  545. if (error || !data?.success) {
  546. errorMessage.value = error?.message || data?.message || "삭제에 실패했습니다.";
  547. return;
  548. }
  549. successMessage.value = data.message || "챌린지가 삭제되었습니다.";
  550. setTimeout(() => router.push("/site-manager/challenge/list"), 800);
  551. } catch (e) {
  552. console.error("[ChallengeDetail] 삭제 실패:", e);
  553. errorMessage.value = "서버 오류가 발생했습니다.";
  554. }
  555. };
  556. const goToList = () => router.push("/site-manager/challenge/list");
  557. const goToEdit = () => router.push(`/site-manager/challenge/edit/${challengeId}`);
  558. onMounted(() => {
  559. loadPlacesAll();
  560. loadChallenge();
  561. });
  562. </script>